
一套 ERP 幾乎不存在裝了就用這回事。同一套定義交付給三家公司,這家要把「客戶」叫成「經銷商」,那家的訂單畫面要少三個欄位,還有一家存檔前要多一道信用額度檢查。三件事的份量差了一個量級,但只要它們全都被表達成程式碼,就會走同一條交付路徑。
Day 1 那張對照表裡,定義驅動之下客製的表現形式是「疊加在標準定義之上的一層資料」。這一層掛在公司上:使用者進的是哪一家公司,就決定了疊上去的是哪一份客製。
本篇說明:
客製化代碼是公司資料上的一個欄位,跟著使用者進的那家公司走。多租戶與集團多公司因此都適用,那是層級選在公司的結果,不是目的。而公司對代碼是多對一:集團旗下幾家共用一份客製就指同一個代碼,各自要不一樣的業務邏輯就各指一個。
客製有兩個面向:套裝已經有的程式怎麼變得不一樣,以及客戶要一支套裝沒有的程式。這一層回答的是前者,後者不走這一層。
框架的作法是把差異放進另一個根目錄。一套部署有一組套裝定義放在 DefinePath,每一個客製化代碼在 CustomizePath 底下有自己的資料夾,資料夾名稱就是那個代碼,裡面只放差異的部分。
{DefinePath}/ ← 套裝定義(節錄)
FormSchema/ TableSchema/ FormLayout/ Language/
ProgramSettings.xml MenuSettings.xml PluginSettings.xml
{CustomizePath}/{customizeId}/ ← 這個代碼的差異,只有這五份
FormLayout/ Language/
ProgramSettings.xml MenuSettings.xml PluginSettings.xml
Day 4 數過框架有十三種定義,進得了客製層的只有其中五種。
這五份各自能改什麼:
| 定義檔 | 能改的部分 | 疊加粒度 | 要交付組件嗎 |
|---|---|---|---|
Language |
欄位標題、表單名稱、訊息文字,以及選項集的文字 | 文字是 key 級,選項集是整份取代 | 否 |
FormLayout |
畫面上出現哪些欄位、怎麼排 | 整份取代 | 否 |
MenuSettings |
選單怎麼分組、放哪些程式 | 整份取代 | 否 |
ProgramSettings |
一個程式的整體行為,以及它怎麼讀寫自己的資料 | ProgId 級,其下屬性級 | 是 |
PluginSettings |
在既有存檔或刪除流程上多一步 | ProgId 級相加 | 是 |
最後一欄是這張表真正的分界線。Language、FormLayout、MenuSettings 這三份改的是定義本身,交付方式就是把幾個 XML 檔放進那個代碼的資料夾;ProgramSettings 與 PluginSettings 這兩份的內容是型別名稱,型別要能載入,組件就得進到主機的 bin。前者是換一份資料,後者仍然是一次部署。
能用輕的就別用重的。改一個標題落在語系資料上,改一張畫面的排法落在版面檔上,多一道存檔前的檢查落在一個 plugin 上,要換掉一支程式整體的行為才輪到客製 BO。同一道檢查改用客製 BO 覆寫也達得到,代價是多一個要跟著每次框架升級維護的類別。
FormSchema 與 TableSchema 永久排除:FormSchema 同時驅動資料庫結構與驗證規則,不只驅動畫面,逐客製分歧會讓實體 schema 裂開。
排除的機制不是一份黑名單,而是三個地方各自沒有那條路。客製層的讀取介面上根本沒有取 FormSchema 的方法;只讀儲存體對這兩個 getter 直接擲 NotSupportedException;客製版的路徑類別也沒有覆寫那兩條路徑,就算有人繞過前兩道,也算不出一個指向客製資料夾的檔名。
這條裁決推出一個必須先納入規劃的限制:客製不能多一個欄位。客製能有的是既有欄位的不同標題、一張把那個欄位藏起來的畫面、一個對它處理方式不同的 BO,或一個負責填它的 plugin。某一家公司真的需要自己的資料時,那是一次對所有人的 schema 變更,用不到的地方讓那個欄位空著。
同一條線還帶走了規則。Day 8 談的宣告式預設值、計算欄與驗證規則住在 FormSchema 裡,所以那些規則對每一家公司都一樣,要讓判斷不一樣只能走 plugin 或客製 BO。
兩份同型別的定義擺在一起,「合起來」不是一個明確的動作。同一個問題在版面上跟在語系上會得到完全不同的答案,所以粒度不是留給呼叫端決定的參數,它被寫死在疊加方法本身:
| 粒度 | 用在 | 查找的語意 |
|---|---|---|
| 整份取代 | FormLayout、MenuSettings、語系的選項集 |
客製有就整份用客製的,套裝那一份完全不看 |
| key 級 | 語系文字 | 客製宣告了那個 key 就用客製值,其餘每一個 key 都來自套裝 |
| ProgId 級,其下屬性級 | 客製 BO、客製 Repository | 客製只覆寫它指名的綁定,留空的沿用套裝 |
| ProgId 級相加 | 業務 plugin | 套裝那一串先跑,客製那一串接在後面 |
整份取代用在沒有直覺合併答案的東西上。一份版面是一個排法,部分合併答不出「這一區塊移走了,底下的欄位跟不跟著走」;一組選項集只有整組看才有意義,逐項合併會讓順序、以及「沒被列到的那一項是什麼意思」兩件事同時變曖昧。要改一組選項,就得把那組應該有的項目全部列出來。
key 級用在彼此無關的東西上。一個欄位標題跟另一個欄位標題之間沒有任何關係,客製檔案裡只放要改的那幾個 key 就夠了:其餘所有標題仍然來自套裝資源,套裝日後新增的翻譯也會自己傳過來。
屬性級是為了一個不會被回報的損失而存在的。ProgramSettings 裡一列程式項目帶著兩個彼此獨立的綁定,一個指 BO、一個指 Repository。若疊加是整列取代,一份只想換掉 BO 的客製會連帶把套裝原本綁的 Repository 一起清掉,而空字串在這裡是合法的「用框架預設」而不是錯誤,所以框架不會擲例外、不會記警告,症狀是那個程式安靜地換了一套資料存取行為。
<!-- Customize/acme/ProgramSettings.xml -->
<ProgramSettings>
<Items>
<ProgramItem ProgId="Order" BusinessObject="Acme.Erp.OrderBO, Acme.Erp" />
</Items>
</ProgramSettings>
套裝那一列的 Repository 與顯示名稱仍然生效。要刻意讓某個綁定退回框架自己的型別,正確的作法是顯式指名那個型別,不是把屬性留空。
相加是唯一不做選擇的粒度,只用在 plugin 上。綁定與步驟不是同一種東西:綁定說的是「這個程式就是這個型別」,只能有一個,客製要換就得擠掉套裝那個;plugin 只是流程上多一步,兩層各加各的不衝突。
代價是客製停不掉套裝的 plugin,框架刻意沒給拿掉某一個的語法。要移除套裝那一步,得繼承 BO 覆寫它。plugin 有哪些時點、每個時點拿得到什麼,是這一章後面的題目。
客製了某張版面,那一份客製就完整擁有它。套裝日後在 FormSchema 新增的欄位,指向那個代碼的公司都不會看到,框架既不合併也不警告。
這是設計意圖而不是缺口。Day 7 那張表裡,走 FormLayout 那一種寫的是哪些欄位不顯示、明細顯示哪幾欄,全由那份檔案說了算。客製層裡排了一份版面,動的就是那份檔案;FormSchema 多一個欄位,並不等於那些公司從此就該顯示它。要讓它出現在那些畫面上是一個決定,執行方式是去改那份客製版面檔。選單同理。
最直覺的實作是啟動時把兩層合成一份完整定義、快取起來,之後整個系統當成單層在讀。擋住這條路的是 Day 12 那個前提:快取給出來的是共用實例,不是複本。
套裝那一層整個行程只有一份,客製那一層由指向同一個代碼的所有 session 共用一份,動了哪一份,就等於動到所有正在讀它的公司。想先合成再快取,寫法只有兩種:把客製的內容寫回套裝那一份,或是另外存一份合好的。前者會讓一家公司的客製落到別家頭上,後者是多一份快取要顧,兩層任何一邊變了都得記得讓它跟著失效。
所以兩層各自快取、各自唯讀,疊加發生在讀那一份定義的當下。客製那一層的隔離用的是既有機制:一個客製化代碼配一個快取容器,容器背後的儲存體只認 {CustomizePath}/{customizeId}/ 底下的檔案,沒有那個檔就回 null,不回頭去找套裝那一層,也不把套裝的內容混進來。「這個代碼沒有客製」與「它客製成空的」因此一直分得開。
四種粒度裡有三種不合成任何東西:整份取代與 key 級是在兩個現成的值之間挑一個,相加產出的是一串型別名稱、兩層的定義物件都原封不動。唯一會把兩層的內容合成一個新物件的是屬性級那一種,節選自 CustomizeOverlay.cs:
if (customizeItem == null) { return baseItem; }
if (baseItem == null) { return customizeItem; }
// WARNING: every ProgramItem property must be merged here. One that is forgotten falls
// back to whole-entry replacement for that property alone.
return new ProgramItem(progId, Prefer(customizeItem.DisplayName, baseItem.DisplayName))
{
BusinessObject = Prefer(customizeItem.BusinessObject, baseItem.BusinessObject),
Repository = Prefer(customizeItem.Repository, baseItem.Repository),
};
只有一層宣告那個 ProgId 時,直接回那一層自己的實例,沒有東西要合;兩層都宣告時建一個新的,兩層的快取實例都不動。
執行期那一半 Day 13 已經建立:客製化代碼是進公司那一段填進 session 的六個值之一,跟著公司同進同出。這裡補兩件事。
session 是唯一來源。伺服端每一個要用到客製的地方都自己去問 session,而客製化代碼在 API 的參數上刻意不存在(節選自 BusinessObject.cs):
protected string GetCurrentCustomizeId()
{
if (AccessToken == Guid.Empty)
return string.Empty;
return SessionInfoService.Get(AccessToken)?.CustomizeId ?? string.Empty;
}
理由在這個值本身:它選的是要讀哪一個代碼的客製檔案,接受呼叫端傳進來等於任何人都能讀任何一家公司的客製。
代碼為空就整條短路:還沒登入、還沒進公司,或這個部署根本沒有客製,第一步就直接走套裝層,不問客製層、不探任何檔案,跟這個功能不存在時走的是同一條路。
疊加規則被抽成一個沒有儲存、沒有快取、沒有 session、沒有 DI 相依的純決策元件,伺服端與用戶端跑的是同一份程式碼。
套裝層快取(全行程一份,唯讀)───┐
├─→ 疊加,依粒度擇一或相加 ─→ 生效值
客製層快取(一個代碼一份,唯讀)─┘
伺服端不先疊,送出去的是原始定義:表單版面與語系資源各有「套裝」與「客製」兩支 API,用戶端把兩層都取回來,再用同一個決策元件挑。挑這一步排在用戶端組裝一張表單定義的路徑上:取回原始定義、在地化、挑版面、套標題,每一個前端都照同樣的順序走一次。
MenuSettings 是例外,伺服端挑好才送:它的粒度是整份取代,兩層都送過去,用戶端也只是二選一。
代價是同一套疊加規則得在兩端各跑一次。因為兩端跑的是同一個元件,照這條路徑組定義的前端不會跟伺服端推導出兩套答案;自己另外組的前端就沒有這個保證,它的疊加規則會跟伺服端各自演化。共用出去的是「怎麼挑」,不是「挑誰」,套的是哪一份客製始終是伺服端依 session 做的決定。
案例的公司資料填了客製化代碼 northwind-demo,客製層底下有一份訂單版面。套裝那份的主檔區塊排了八個欄位,客製這一份只排五個(Order.FormLayout.xml):
<LayoutField FieldName="sys_id" Caption="Order No" />
<LayoutField FieldName="order_date" Caption="Order Date" />
<LayoutField FieldName="customer_rowid" Caption="Customer" ControlType="ButtonEdit" DisplayFields="ref_customer_id,ref_customer_name" />
<LayoutField FieldName="status" Caption="Status" ControlType="DropDownEdit" />
<LayoutField FieldName="total_amount" Caption="Total Amount" ReadOnly="true" />
這家公司不自己出貨、也不記業務員,所以業務員、貨運商、運費三欄不排進畫面。片段裡那些英文標題執行期不生效,標題的來源是語系資源,不是版面檔;畫面上那個「經銷商」就來自同一個代碼底下的另一份客製,而那一份走的是 key 級。

整份取代的代價就在同一個檔案裡:明細那一段一行都沒有改,客製檔仍然得把它整段抄一次。少抄的話不是「沿用套裝的明細」,是那張表單從此沒有明細。
欄位本身並沒有消失。FormSchema 與資料表照舊,那三個欄位還在,只是這一份版面不顯示它們。一個換掉整張畫面排法的需求,在客製層裡就是整份抄一次;粒度選在整份上換到的是這個:你完整擁有它,也完整維護它。
客製層真正在回答的問題是:不一樣的地方要落在哪一份定義上,以及那個決定掛在哪一層。落點決定了交付方式,也決定了這一份客製跟套裝之間還剩多少關係。
FormSchema 與 TableSchema 永久排除,推論是客製不能多一個欄位最該帶走的是粒度那一節。粒度真正決定的不是客製能改多細,是套裝日後改了、這一份客製跟不跟得上。key 級、屬性級與相加這三種,套裝之後新增的翻譯、新增的綁定、新增的 plugin,客製過的照樣拿得到;整份取代那一種拿不到,而且不會有任何地方提醒你。
這不是一路選細就好的問題,版面與選單本來就沒有部分合併的直覺答案,選粗是唯一答得出來的選項。能做的是知道自己買了什麼:每一次選擇粒度,都是在決定這一份客製未來要從套裝的演進裡分到多少。
明天談這一層裡最常被用到的那一份:一套要賣到多個地區的系統,多語系為什麼不走 .resx,而是走定義檔。
本系列同步發表於 HackMD,完整目錄